承接上一篇,這篇來聊聊 <a download> 跟 Content-Disposition 是如何處理下載的檔名
<a download> 範例<a download="filename.jpg"></a>
Content-Disposition: attachment; filename=filename.jpg
Content-Disposition: attachment; filename="file name.jpg"
Content-Disposition: attachment; filename*=UTF-8''file%20name.jpg
當兩者一起設定時
Content-Disposition: attachment; filename=1.txt
<a download="2.txt">
瀏覽器會優先使用 Content-Disposition 的值,參考 MDN 的描述
If the header specifies a filename, it takes priority over a filename specified in the download attribute.
寫個 PoC
import http from "http";
const httpServer5000 = http.createServer((req, res) => {
if (req.url === "/test") {
res.setHeader("Content-Type", "text/plain");
res.setHeader("Content-Disposition", "attachment; filename=1.txt");
res.end("test");
return;
}
});
httpServer5000.listen(5000);
const httpServer5001 = http.createServer((req, res) => {
res.setHeader("content-type", "text/html");
res.end('<a href="http://localhost:5000/test" download>download</a>');
return;
});
httpServer5001.listen(5001);
點擊 "download" 後,最終下載的檔名確實會以 Content-Disposition 宣告的為主

接著,我們會來介紹 Content-Disposition filename 的各項參數
filename*=UTF-8''URL-Encoded-Value 的格式根據 RFC6266 Section 5 的範例,建議 filename 跟 filename* 兩者同時設定,確保向後兼容性
Content-Disposition: attachment; filename="EURO rates"; filename*=utf-8''%e2%82%ac%20rates
而根據 RFC6266 Section 4.3 的原文,當兩者同時設定,會優先選擇 filename*
when both "filename" and "filename*" are present in a single header field value, recipients SHOULD pick "filename*" and ignore "filename"
filename=中文.jpgPoC
import http from "http";
import { readFileSync } from "fs";
import { join } from "path";
const image = readFileSync(join(import.meta.dirname, "image.jpg"));
const httpServer5000 = http.createServer((req, res) => {
if (req.url === "/test") {
const socket = res.socket;
socket?.write(`HTTP/1.1 200 Ok\r\n`);
socket?.write(`Content-Length: ${Buffer.byteLength(image)}\r\n`);
socket?.write(`Content-Disposition: attachment; filename=中文.jpg\r\n`);
socket?.write(`Content-Type: image/jpeg\r\n\r\n`);
socket?.write(image);
}
});
httpServer5000.listen(5000);
const httpServer5001 = http.createServer((req, res) => {
res.setHeader("content-type", "text/html");
res.end('<a href="http://localhost:5000/test" download>download</a>');
return;
});
httpServer5001.listen(5001);
點擊 "download" 後,檔名在作業系統有正確呈現

但 F12 > Network 呈現的檔名是錯的

嘗試用 curl -v "http://localhost:5000/test" --output test.jpg,確保上面的 PoC 是正確的
* Request completely sent off
< HTTP/1.1 200 Ok
< Content-Length: 1374458
< Content-Disposition: attachment; filename=中文.jpg
< Content-Type: image/jpeg
wc -c test.jpg 確認 Content-Length 跟實際檔案的 bytes 符合
1374458 test.jpg
至於 䏿–‡.jpg 是什麼呢?這其實是編碼轉換的問題,瀏覽器看到 filename=,預設用 ISO-8859-1 的編碼來呈現,轉換過程為:
| UTF-8 | Hex | ISO-8859-1 |
|---|---|---|
| 中文 | e4 b8 ad e6 96 87 | 䏿–‡.jpg |
寫個 PoC 來驗證 䏿–‡
import http from "http";
const httpServer5000 = http.createServer((req, res) => {
if (req.url === "/ISO-8859-1") {
const buffer = Buffer.from("中文", "utf8");
res.setHeader("Content-Type", "text/html; charset=iso-8859-1");
res.end(buffer);
return;
}
});
httpServer5000.listen(5000);
Chrome 訪問 http://localhost:5000/ISO-8859-1 ,可以正確看到 䏿–‡ 了~

現在很多網站、工具預設都用 UTF-8,所以使用 charset=iso-8859-1 來測試,會比較準確
filename=hello world.jpgPoC
import http from "http";
import { readFileSync } from "fs";
import { join } from "path";
const image = readFileSync(join(import.meta.dirname, "image.jpg"));
const httpServer5000 = http.createServer((req, res) => {
if (req.url === "/test") {
res.setHeader("Content-Type", "image/jpeg");
res.setHeader(
"Content-Disposition",
"attachment; filename=hello world.jpg",
);
res.end(image);
return;
}
});
httpServer5000.listen(5000);
const httpServer5001 = http.createServer((req, res) => {
res.setHeader("content-type", "text/html");
res.end('<a href="http://localhost:5000/test" download>download</a>');
return;
});
httpServer5001.listen(5001);
實測後,檔名的空白,有正確呈現在 F12 > Network 跟作業系統


filename=hello%0D%0Aworld.jpg%0D%0A 是 CRLF 的 URL Encode 版本
PoC
import http from "http";
import { readFileSync } from "fs";
import { join } from "path";
const image = readFileSync(join(import.meta.dirname, "image.jpg"));
const httpServer5000 = http.createServer((req, res) => {
if (req.url === "/test") {
res.setHeader("Content-Type", "image/jpeg");
res.setHeader(
"Content-Disposition",
"attachment; filename=hello%0D%0Aworld.jpg",
);
res.end(image);
return;
}
});
httpServer5000.listen(5000);
const httpServer5001 = http.createServer((req, res) => {
res.setHeader("content-type", "text/html");
res.end('<a href="http://localhost:5000/test" download>download</a>');
return;
});
httpServer5001.listen(5001);
實測後,檔名有正確呈現在 F12 > Network,但下載到作業系統後,%0D%0A 被轉換成 __

根據 MDN 文件的描述
Browsers may apply transformations to conform to the file system requirements, such as converting path separators (/ and \) to underscores (_).
瀏覽器這樣做,除了正規化檔名,讓各個作業系統的 file system 可以正確呈現,還可以避免各種資安隱患,例如 Path Traversal
在這篇文章,我們學到了
<a download> 跟 Content-Disposition 同時設定 filename 時的優先順序Content-Disposition 的 filename 跟 filename* 參數的差異Content-Disposition 的 filename 參數遇到 中文 會怎麼處理Content-Disposition 的 filename 參數遇到 空白 會怎麼處理Content-Disposition 的 filename 參數遇到 特殊字元 會怎麼處理